iT邦幫忙

2025 iThome 鐵人賽

DAY 0
0
Modern Web

前端工程師的 Modern Web 實踐之道系列 第 13

使用者體驗最佳化:從載入效能到互動設計的現代化實踐

  • 分享至 

  • xImage
  •  

系列文章: 前端工程師的 Modern Web 實踐之道 - Day 13
預計閱讀時間: 12 分鐘
難度等級: ⭐⭐⭐⭐☆

🎯 今日目標

在前一篇文章中,我們探討了響應式設計的現代化技術。今天我們將深入使用者體驗最佳化的核心議題 - 如何透過技術手段提升使用者感知效能和互動品質。這不只是技術問題,更是產品成功的關鍵因素。

為什麼要關注使用者體驗最佳化?

想像這個場景:你負責開發一個企業級安全事件監控系統,需要處理數千筆即時事件資料。使用者抱怨:

  • 載入慢: "點擊搜尋後等了 10 秒才看到結果"
  • 互動卡頓: "捲動表格時畫面會凍結"
  • 狀態不明: "不知道資料是否還在載入"
  • 重複操作: "每個事件類型都要寫一次相同的邏輯"

這些痛點不僅影響使用者體驗,也反映了程式碼架構問題。今天我們將透過真實專案案例,學習如何系統性地解決這些問題。


🔍 深度分析:使用者體驗的技術本質

問題背景:一個真實的重構案例

讓我分享一個來自企業級安全監控系統的真實案例。原始程式碼存在嚴重的重複問題:

// ❌ 問題代碼: useSequentialAntiMalwareEvents.js (249 行)
export const useSequentialAntiMalwareEvents = (initialFilters = {}) => {
  const [allEvents, setAllEvents] = useState([]);
  const [currentScanRange, setCurrentScanRange] = useState(null);
  const [loadingState, setLoadingState] = useState({
    isLoading: false,
    message: '準備搜尋反惡意軟體事件...',
    hasMorePages: false,
    isComplete: false,
    currentPage: 0,
  });

  const [searchAntiMalwareEvents, { isFetching }] = useLazySearchAntiMalwareEventsQuery();

  // ... 接下來 240 行幾乎相同的邏輯
  const fetchNextBatch = useCallback(async () => {
    // 複雜的分頁載入邏輯
  }, [/* 大量依賴 */]);

  return {
    allEvents,
    loadingState,
    startSearch,
    stopSearch,
    // ...
  };
};

問題嚴重性:

  • 16 個幾乎相同的 hook (AntiMalware, Firewall, IPS, AC, DC...)
  • 每個 249 行 × 16 = 3,984 行重複程式碼
  • 維護噩夢:修一個 bug 要改 16 個地方
  • 使用者體驗不一致:不同模組的載入行為可能不同

使用者體驗問題的根源分析

1. 效能問題根源

// 問題: 使用 useState 管理複雜狀態
const [loadingState, setLoadingState] = useState({
  isLoading: false,
  hasMorePages: false,
  currentPage: 0,
  // ... 多個相關狀態
});

// 每次更新都要:
setLoadingState(prev => ({
  ...prev,
  isLoading: true,
  message: `載入第 ${currentPage} 頁...`,
  currentPage: prev.currentPage + 1
}));

問題點:

  • 狀態更新邏輯散落各處
  • 容易出現狀態不一致
  • 難以追蹤狀態變化歷史

2. 互動回饋缺失

// 使用者看不到進度
setLoadingState({ isLoading: true });
await fetchData(); // 黑箱等待
setLoadingState({ isLoading: false });

使用者感知:

  • ❓ 是在載入還是卡住了?
  • ❓ 已經載入多少資料?
  • ❓ 什麼時候會完成?

💡 解決方案:分層次的體驗最佳化策略

第一層:狀態管理重構 (技術基礎)

使用 useReducer 建立可預測的狀態機

// ✅ 改進方案: useSequentialEvents.js (核心邏輯)

// 定義清晰的狀態結構
const initialState = {
  events: [],
  scanRange: null,
  currentPage: 0,
  isLoading: false,
  isComplete: false,
  hasMorePages: false,
  error: null,
};

// 使用 Reducer 管理狀態轉換
function eventsReducer(state, action) {
  switch (action.type) {
    case 'START':
      return { ...initialState, isLoading: true };

    case 'BATCH_LOADED':
      return {
        ...state,
        events: [...state.events, ...action.events],
        scanRange: action.scanRange || state.scanRange,
        currentPage: state.currentPage + 1,
        hasMorePages: !!action.nextToken,
        isLoading: false,
      };

    case 'COMPLETE':
      return {
        ...state,
        isComplete: true,
        hasMorePages: false,
        isLoading: false
      };

    case 'ERROR':
      return {
        ...state,
        error: action.error,
        isLoading: false,
        hasMorePages: false
      };

    case 'RESET':
      return initialState;

    default:
      return state;
  }
}

技術優勢:

  • ✅ 狀態轉換邏輯集中管理
  • ✅ 可預測的狀態更新路徑
  • ✅ 易於測試和除錯
  • ✅ 天然支援 DevTools 追蹤

核心 Hook 實作

/**
 * 通用的序列事件載入 Hook
 * @param {Object} params - Hook 參數
 * @param {Function} params.queryHook - RTKQ lazy query hook
 * @param {Object} params.config - 載入設定
 */
export const useSequentialEvents = ({ queryHook, config = {} }) => {
  const [state, dispatch] = useReducer(eventsReducer, initialState);
  const [triggerQuery, { isFetching }] = queryHook();

  // 使用 ref 管理控制狀態 (避免閉包問題)
  const controlRef = useRef({
    nextToken: null,
    filters: {},
    active: false,
    timeoutId: null,
  });

  // 清理函式 - 確保沒有記憶體洩漏
  const cleanup = useCallback(() => {
    controlRef.current.active = false;
    if (controlRef.current.timeoutId) {
      clearTimeout(controlRef.current.timeoutId);
      controlRef.current.timeoutId = null;
    }
  }, []);

  // 批次載入邏輯
  const fetchBatch = useCallback(async () => {
    if (!controlRef.current.active) return;

    try {
      const response = await triggerQuery({
        ...controlRef.current.filters,
        nextToken: controlRef.current.nextToken,
        searchTimePerBatch: config.searchTimePerBatch || 20,
        eventCountPerBatch: config.eventCountPerBatch || 500,
        maxEventCount: config.maxEventCount || 1000,
      }).unwrap();

      if (!response?.events) {
        dispatch({ type: 'COMPLETE' });
        cleanup();
        return;
      }

      // 分發批次載入成功事件
      dispatch({
        type: 'BATCH_LOADED',
        events: response.events,
        scanRange: response.scanRange,
        nextToken: response.nextToken,
      });

      // 繼續載入下一批 (如果有)
      if (response.nextToken && controlRef.current.active) {
        controlRef.current.nextToken = response.nextToken;
        // 500ms 延遲避免 API 過載
        controlRef.current.timeoutId = setTimeout(fetchBatch, 500);
      } else {
        dispatch({ type: 'COMPLETE' });
        cleanup();
      }
    } catch (err) {
      dispatch({ type: 'ERROR', error: err });
      cleanup();
    }
  }, [triggerQuery, config, cleanup]);

  // 公開 API
  const start = useCallback((filters = {}) => {
    cleanup();
    controlRef.current = {
      nextToken: null,
      filters,
      active: true,
      timeoutId: null,
    };
    dispatch({ type: 'START' });
    controlRef.current.timeoutId = setTimeout(fetchBatch, 100);
  }, [fetchBatch, cleanup]);

  const stop = useCallback(() => {
    cleanup();
    dispatch({ type: 'COMPLETE' });
  }, [cleanup]);

  const reset = useCallback(() => {
    cleanup();
    dispatch({ type: 'RESET' });
  }, [cleanup]);

  return {
    allEvents: state.events,
    currentScanRange: state.scanRange,
    loadingState: {
      isLoading: state.isLoading,
      hasMorePages: state.hasMorePages,
      isComplete: state.isComplete,
      currentPage: state.currentPage,
    },
    error: state.error,
    isLoading: isFetching || state.isLoading,
    startSearch: start,
    stopSearch: stop,
    clearData: reset,
    stats: {
      totalEvents: state.events.length,
      totalPages: state.currentPage,
      isComplete: state.isComplete,
    },
  };
};

設計亮點:

  1. 狀態與控制分離

    • state: 使用 useReducer 管理可預測狀態
    • controlRef: 使用 useRef 管理命令式控制邏輯
  2. 記憶體洩漏防護

    • 清理函式統一管理 timeout
    • 組件卸載時自動停止載入
  3. 錯誤處理完整

    • 網路錯誤自動捕獲
    • 狀態自動回滾到安全狀態

第二層:專用 Hook 適配 (可複用性)

// ✅ 各事件類型的適配層 (每個只需 20 行!)

// useSequentialAntiMalwareEvents.js
import { useLazySearchAntiMalwareEventsQuery } from 'src/rtkqApis/antiMalwareRtkqApi';
import { useSequentialEvents } from './useSequentialEvents';

/**
 * 反惡意軟體事件的序列載入 Hook
 * @param {Object} initialFilters - 初始過濾條件
 */
export const useSequentialAntiMalwareEvents = (initialFilters = {}) => {
  return useSequentialEvents({
    queryHook: useLazySearchAntiMalwareEventsQuery,
    config: {
      searchTimePerBatch: 20,
      eventCountPerBatch: 500,
      maxEventCount: 1000,
    },
  });
};

export default useSequentialAntiMalwareEvents;

重構成果對比:

指標 重構前 重構後 改善
總程式碼行數 3,984 行 ~300 行 -92%
核心邏輯 16 份重複 1 份共用 -94%
適配層程式碼 N/A 16 × 20 行 乾淨簡潔
維護成本 高 (改 16 次) 低 (改 1 次) -94%

🎨 第三層:互動體驗設計

進度回饋設計模式

// ✅ 在 UI 層提供清晰的載入狀態回饋

function EventsTable() {
  const {
    allEvents,
    loadingState,
    startSearch,
    stopSearch,
    stats,
  } = useSequentialAntiMalwareEvents();

  return (
    <div>
      {/* 載入狀態指示器 */}
      {loadingState.isLoading && (
        <LoadingProgress
          currentPage={loadingState.currentPage}
          totalEvents={stats.totalEvents}
          hasMore={loadingState.hasMorePages}
        />
      )}

      {/* 完成狀態 */}
      {loadingState.isComplete && !loadingState.isLoading && (
        <CompletionBanner
          totalEvents={stats.totalEvents}
          totalPages={stats.totalPages}
        />
      )}

      {/* 資料表格 */}
      <DataGrid
        data={allEvents}
        loading={loadingState.isLoading}
      />

      {/* 控制按鈕 */}
      <div>
        <Button onClick={() => startSearch(filters)}>
          搜尋
        </Button>
        {loadingState.hasMorePages && (
          <Button onClick={stopSearch} variant="secondary">
            停止載入
          </Button>
        )}
      </div>
    </div>
  );
}

漸進式資料載入 UX 模式

// ✅ 分批顯示資料,避免長時間等待

function LoadingProgress({ currentPage, totalEvents, hasMore }) {
  return (
    <div className="loading-progress">
      <Spinner size="small" />
      <div className="progress-info">
        <strong>正在載入第 {currentPage} 頁</strong>
        <span>已載入 {totalEvents.toLocaleString()} 筆事件</span>
        {hasMore && <span>繼續載入中...</span>}
      </div>
    </div>
  );
}

使用者感知改善:

體驗指標 改善前 改善後
首次回饋時間 10 秒 (全部載入完) 0.5 秒 (第一批資料)
載入狀態可見性 ❌ 無提示 ✅ 即時進度
使用者控制力 ❌ 無法中斷 ✅ 隨時停止
錯誤處理 ❌ 直接失敗 ✅ 友善提示

📊 Core Web Vitals 最佳化實踐

關鍵效能指標

// ✅ 監控 Core Web Vitals

import { onCLS, onFID, onLCP } from 'web-vitals';

function reportWebVitals() {
  // Largest Contentful Paint (最大內容繪製)
  onLCP((metric) => {
    console.log('LCP:', metric.value);
    // 目標: < 2.5 秒
    if (metric.value > 2500) {
      analytics.track('performance_issue', {
        metric: 'LCP',
        value: metric.value,
        page: window.location.pathname,
      });
    }
  });

  // First Input Delay (首次輸入延遲)
  onFID((metric) => {
    console.log('FID:', metric.value);
    // 目標: < 100 毫秒
  });

  // Cumulative Layout Shift (累積佈局偏移)
  onCLS((metric) => {
    console.log('CLS:', metric.value);
    // 目標: < 0.1
  });
}

效能最佳化清單

✅ 首屏載入最佳化

// 1. 程式碼分割 (Code Splitting)
const EventViewer = lazy(() => import('./features/EventViewer'));

function App() {
  return (
    <Suspense fallback={<AppLoading />}>
      <EventViewer />
    </Suspense>
  );
}

// 2. 路由層級懶載入
const routes = [
  {
    path: '/anti-malware',
    component: lazy(() => import('./containers/AntiMalware')),
  },
  {
    path: '/firewall',
    component: lazy(() => import('./containers/Firewall')),
  },
];

// 3. 資源預載入 (Preload)
<link
  rel="preload"
  href="/api/user-profile"
  as="fetch"
  crossOrigin="anonymous"
/>

✅ 執行時效能最佳化

// 1. 虛擬化長列表
import { useVirtualizer } from '@tanstack/react-virtual';

function VirtualizedEventList({ events }) {
  const parentRef = useRef();

  const virtualizer = useVirtualizer({
    count: events.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 48, // 每行高度
    overscan: 5, // 預渲染行數
  });

  return (
    <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
      <div style={{ height: `${virtualizer.getTotalSize()}px` }}>
        {virtualizer.getVirtualItems().map((virtualRow) => (
          <EventRow
            key={virtualRow.index}
            event={events[virtualRow.index]}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              height: `${virtualRow.size}px`,
              transform: `translateY(${virtualRow.start}px)`,
            }}
          />
        ))}
      </div>
    </div>
  );
}

// 2. 記憶化計算
const processedEvents = useMemo(() => {
  return events.map(event => ({
    ...event,
    formattedTime: formatTimestamp(event.eventTime),
    severityLevel: getSeverityLevel(event.severity),
  }));
}, [events]);

// 3. 防抖搜尋
const debouncedSearch = useMemo(
  () => debounce((query) => {
    startSearch({ query });
  }, 300),
  [startSearch]
);

🎯 完整實戰案例:企業級事件監控系統

架構設計

src/features/EventViewer/
├── hooks/
│   ├── useSequentialEvents.js          # 核心邏輯 (通用)
│   ├── useSequentialAntiMalwareEvents.js # 適配層
│   ├── useSequentialFirewallEvents.js    # 適配層
│   └── ...                               # 其他 14 個適配層
├── components/
│   ├── EventsTable.jsx                 # 資料表格
│   ├── LoadingProgress.jsx             # 載入進度
│   └── FilterPanel.jsx                 # 過濾面板
└── containers/
    └── AntiMalware/
        └── AntiMalware.jsx             # 業務容器

完整使用範例

// containers/AntiMalware/AntiMalware.jsx

import React, { useState, useCallback } from 'react';
import { useSequentialAntiMalwareEvents } from '../../hooks/useSequentialAntiMalwareEvents';
import EventsTable from '../../components/EventsTable';
import LoadingProgress from '../../components/LoadingProgress';
import FilterPanel from '../../components/FilterPanel';

function AntiMalware() {
  const [filters, setFilters] = useState({});

  const {
    allEvents,
    loadingState,
    error,
    startSearch,
    stopSearch,
    clearData,
    stats,
  } = useSequentialAntiMalwareEvents();

  const handleSearch = useCallback(() => {
    clearData(); // 清除舊資料
    startSearch(filters);
  }, [filters, startSearch, clearData]);

  const handleStop = useCallback(() => {
    stopSearch();
  }, [stopSearch]);

  return (
    <div className="anti-malware-container">
      <h1>反惡意軟體事件監控</h1>

      {/* 過濾面板 */}
      <FilterPanel
        filters={filters}
        onChange={setFilters}
        onSearch={handleSearch}
        disabled={loadingState.isLoading}
      />

      {/* 錯誤提示 */}
      {error && (
        <Alert variant="error">
          載入失敗: {error.message}
        </Alert>
      )}

      {/* 載入進度 */}
      {loadingState.isLoading && (
        <LoadingProgress
          currentPage={loadingState.currentPage}
          totalEvents={stats.totalEvents}
          hasMore={loadingState.hasMorePages}
          onStop={handleStop}
        />
      )}

      {/* 完成提示 */}
      {loadingState.isComplete && !loadingState.isLoading && (
        <Alert variant="success">
          ✅ 載入完成! 共 {stats.totalEvents.toLocaleString()} 筆事件
          (分 {stats.totalPages} 頁載入)
        </Alert>
      )}

      {/* 資料表格 */}
      <EventsTable
        events={allEvents}
        loading={loadingState.isLoading}
        emptyMessage={
          stats.totalEvents === 0 && loadingState.isComplete
            ? '查無資料'
            : '請設定過濾條件並開始搜尋'
        }
      />

      {/* 統計資訊 */}
      <div className="stats-footer">
        <span>總筆數: {stats.totalEvents.toLocaleString()}</span>
        <span>載入頁數: {stats.totalPages}</span>
        {loadingState.hasMorePages && (
          <span className="more-indicator">
            ⏳ 更多資料載入中...
          </span>
        )}
      </div>
    </div>
  );
}

export default AntiMalware;

📋 本日重點回顧

  1. 核心概念: 使用者體驗最佳化是技術與設計的結合,需要從效能、互動、回饋三個層面系統性思考。

  2. 關鍵技術:

    • 使用 useReducer 建立可預測的狀態管理
    • 透過抽象化消除重複程式碼 (3,984 行 → 300 行)
    • 實作漸進式載入提升感知效能
  3. 實踐要點:

    • 狀態與控制邏輯分離 (reducer + ref)
    • 完整的錯誤處理和清理機制
    • 清晰的使用者回饋設計

🎯 最佳實踐建議

✅ 推薦做法

  1. 狀態管理選擇

    // ✅ 複雜狀態用 useReducer
    const [state, dispatch] = useReducer(reducer, initialState);
    
    // ✅ 簡單狀態用 useState
    const [isOpen, setIsOpen] = useState(false);
    
  2. 漸進式回饋

    // ✅ 分批顯示結果
    dispatch({ type: 'BATCH_LOADED', events });
    
    // ❌ 全部載入完才顯示
    // setEvents(await loadAll());
    
  3. 效能監控

    // ✅ 量化指標追蹤
    onLCP((metric) => analytics.track('LCP', metric.value));
    
    // ❌ 憑感覺判斷
    

❌ 避免陷阱

  1. 過早最佳化

    // ❌ 所有東西都虛擬化
    <VirtualList items={5} /> // 只有 5 個項目
    
    // ✅ 超過 100 個才虛擬化
    {items.length > 100 ? <VirtualList /> : <SimpleList />}
    
  2. 忽略錯誤狀態

    // ❌ 沒有錯誤處理
    const data = await fetch(url).then(r => r.json());
    
    // ✅ 完整錯誤處理
    try {
      const data = await fetch(url).then(r => r.json());
    } catch (err) {
      dispatch({ type: 'ERROR', error: err });
    }
    
  3. 記憶體洩漏

    // ❌ 忘記清理 timer
    useEffect(() => {
      const id = setTimeout(fetchData, 500);
    }, []);
    
    // ✅ 正確清理
    useEffect(() => {
      const id = setTimeout(fetchData, 500);
      return () => clearTimeout(id);
    }, []);
    

🤔 延伸思考

  1. 架構設計: 如果需要支援 20 種以上的事件類型,如何設計更靈活的適配機制?

  2. 效能權衡: 虛擬化列表雖然解決了大量資料渲染問題,但會失去原生瀏覽器的搜尋功能 (Ctrl+F),如何取捨?

  3. 實戰挑戰: 設計一個支援以下功能的通用載入 Hook:

    • 無限滾動載入
    • 錯誤重試機制
    • 樂觀更新 (Optimistic Update)
    • 資料快取與失效

上一篇
響應式設計 2.0:Container Queries 與現代化布局技術
系列文
前端工程師的 Modern Web 實踐之道13
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言